5.09. ООП в Kotlin
ООП в Kotlin
Пример класса
class Unit {
var name = "Имя"
var intel = 10
var agility = 10
var strength = 10
var health = 100
var mana = 50
var level = 1
val damage: Int
get() = (intel + agility + strength) + (level * 2)
fun attack(target: Unit) {
println("$name атакует ${target.name} и наносит $damage единиц урона.")
target.health -= damage
println("${target.name} теперь имеет $health здоровья.")
}
}
fun main() {
val warrior = Unit()
warrior.name = "Воин"
warrior.intel = 5
warrior.agility = 15
warrior.strength = 30
val mage = Unit()
mage.name = "Маг"
mage.intel = 35
mage.agility = 10
mage.strength = 5
warrior.attack(mage)
mage.attack(warrior)
}
Ключевое слово class создаёт новый класс. В Kotlin классы по умолчанию являются финальными и не требуют явного указания модификатора доступа для базового примера. Имя класса начинается с заглавной буквы в соответствии со стилем кодирования языка.
Свойства объявляются через ключевые слова var и val. Ключевое слово var определяет изменяемое свойство, значение которого можно менять после создания объекта. Ключевое слово val определяет свойство только для чтения. Для вычисляемого урона используется val с кастомным геттером, который пересчитывает значение при каждом обращении.
Свойство damage не хранит значение напрямую. Вместо этого при каждом обращении к этому свойству выполняется тело геттера, возвращающее результат арифметического выражения на основе текущих характеристик объекта. Такой подход гарантирует актуальность урона без дополнительных вызовов методов.
Метод attack объявляется через ключевое слово fun. Параметр метода указывает тип принимаемого объекта — другой экземпляр класса Unit. Внутри метода используется строковая интерполяция: выражения в фигурных скобках внутри строки с символом доллара вычисляются и подставляются в текст сообщения. Метод напрямую изменяет свойство health целевого объекта.
Функция main объявляется на верхнем уровне файла без привязки к классу. Такая организация характерна для Kotlin и упрощает структуру небольших программ. Функция автоматически вызывается при запуске скомпилированного приложения.
Оператор Unit() вызывает конструктор класса и создаёт новый объект. После создания объекта значения его свойств изменяются через присваивание. Каждый объект сохраняет собственные значения характеристик независимо от других экземпляров.
Функция println выводит текст в консоль и добавляет символ новой строки. Строковая интерполяция позволяет встраивать значения переменных и выражений непосредственно в текст без операций конкатенации. Такой синтаксис делает код более читаемым и лаконичным.
Последовательные вызовы warrior.attack(mage) и mage.attack(warrior) демонстрируют передачу объектов в качестве аргументов методов и изменение их внутреннего состояния. Каждый вызов метода attack производит побочный эффект — уменьшение здоровья целевого объекта, что отражает основные принципы объектно-ориентированного программирования.
Объектно-ориентированное программирование
Объектно-ориентированное программирование (ООП) остаётся одной из ключевых парадигм разработки программного обеспечения на протяжении уже нескольких десятилетий, и Kotlin, как современный статически типизированный язык общего назначения, реализует её принципы с рядом уточнений, направленных на повышение выразительности, безопасности и удобства использования. В отличие от чисто функциональных или гибридных языков, где ООП может быть опциональным или второстепенным, в Kotlin объектная модель образует основу языковой архитектуры: даже базовые типы данных обёрнуты в объекты, а классы выступают как единственная структурная единица для инкапсуляции состояния и поведения.
Kotlin основан на той же фундаментальной системе, что используется в Java и других JVM-языках, однако вводит ряд синтаксических и семантических улучшений, устраняющих исторические недостатки, упрощающих типовой код и повышающих надёжность. Эти улучшения не нарушают совместимости с существующей Java-экосистемой, а, напротив, позволяют постепенно модернизировать legacy-код, сохраняя полную взаимодействуемость.
Классы и объекты
В Kotlin классы объявляются с помощью ключевого слова class, после которого следует идентификатор и, необязательно, список параметров первичного конструктора. Первичный конструктор — одна из наиболее заметных особенностей Kotlin: он интегрирован непосредственно в заголовок класса и позволяет объявлять свойства (val, var) и инициализировать их без необходимости писать отдельный конструкторный блок, как это делается в Java.
Пример:
class Person(val name: String, var age: Int) {
fun greet() {
println("Hello, my name is $name")
}
}
В этом объявлении val name и var age — полноценные свойства экземпляра: name неизменяемо (аналог final в Java), age — изменяемо. Значения передаются при создании объекта:
val person = Person("Alice", 30)
person.greet() // Вывод: Hello, my name is Alice
Синтаксис Person("Alice", 30) инициирует вызов первичного конструктора и создание нового экземпляра. В отличие от Java, где каждая строка вида new X() сопровождается явным указанием оператора new, Kotlin использует более лаконичную запись, при этом сохраняя строгую типизацию: компилятор проверяет соответствие типов аргументов, а также разрешает перегрузку конструкторов через вторичные конструкторы или factory-функции.
Каждый класс в Kotlin по умолчанию закрыт для наследования — это принципиальное изменение по сравнению с Java, где классы открыты по умолчанию. Такое решение мотивировано практиками проектирования: большинство классов не предназначено для расширения, и их непреднамеренное наследование может привести к нарушению инвариантов и трудноуловимым ошибкам. Чтобы разрешить наследование, класс должен быть явно объявлен как open:
open class Animal(val name: String)
class Cat(name: String) : Animal(name)
Здесь Cat наследует от Animal, передавая имя через конструктор базового класса. Обратите внимание: синтаксис : заменяет Java-овый extends, а вызов конструктора родителя включён прямо в объявление наследования. Если базовый класс содержит первичный конструктор, его аргументы должны быть переданы непосредственно в месте объявления класса-наследника — это исключает ошибки, связанные с отложенной инициализацией или пропущенными вызовами super().
Наследование в Kotlin строго одноуровневое по базовым типам: язык не поддерживает множественное наследование классов, что сохраняет стабильность иерархии типов. Однако для реализации полиморфного поведения Kotlin предоставляет интерфейсы — они могут включать реализацию по умолчанию, что делает их мощным средством композиции поведения без нарушения принципа единственной цепочки наследования.
Инкапсуляция и управление видимостью
Инкапсуляция — механизм, обеспечивающий сокрытие внутреннего состояния объекта и предоставление доступа к нему только через строго определённые методы. В Kotlin эта концепция реализована через систему модификаторов видимости и свойств с геттерами и сеттерами.
Модификаторы доступа в Kotlin:
public— по умолчанию, доступно из любого места;private— доступно только внутри объявляющего класса или файла (для top-level элементов);protected— доступно внутри объявляющего класса и его подклассов;internal— доступно в пределах модуля компиляции (обычно — одного Gradle/Maven-проекта).
Пример с private:
class User(private val login: String) {
private var _passwordHash: String? = null
fun setPassword(password: String) {
_passwordHash = hash(password)
}
private fun hash(s: String): String = /* ... */
}
Здесь login неизменяем и недоступен извне, _passwordHash — внутреннее представление, изменяемое только через метод setPassword. Отсутствие публичного сеттера гарантирует, что пароль всегда проходит хеширование и не может быть установлен напрямую.
Важной особенностью Kotlin является то, что свойства (val/var) не обязаны соответствовать физическим полям. Компилятор автоматически генерирует байт-код с приватными полями и публичными методами-аксессорами (getLogin(), getPasswordHash() и т.д.), совместимыми с JavaBeans-спецификацией. При этом разработчик может переопределить поведение геттера или сеттера без изменения сигнатуры:
class Counter {
var count: Int = 0
private set // сеттер приватный, геттер — по умолчанию public
fun increment() { count++ }
}
Такой подход позволяет сохранить полную совместимость с Java-библиотеками и фреймворками, ожидая наличие геттеров и сеттеров, при этом оставаясь выразительным и безопасным на уровне исходного кода.
Полиморфизм и динамическое связывание
Полиморфизм в Kotlin реализуется через наследование и переопределение методов. Метод в базовом классе должен быть объявлен как open, чтобы его можно было переопределить, а в подклассе — с ключевым словом override:
open class Shape {
open fun draw() {
println("Generic shape")
}
}
class Circle : Shape() {
override fun draw() {
println("Drawing a circle")
}
}
Вызов draw() на ссылке типа Shape, указывающей на объект Circle, приведёт к выполнению переопределённой версии — так работает динамическое связывание (late binding). Эта модель полностью совместима с JVM-механизмом виртуальных вызовов и позволяет строить гибкие иерархии.
Kotlin также поддерживает абстрактные классы и методы (abstract class, abstract fun), а также интерфейсы с реализацией по умолчанию, что расширяет возможности композиции. Например:
interface Drawable {
fun draw()
fun render() {
println("Default rendering logic")
draw()
}
}
class Rectangle : Drawable {
override fun draw() {
println("Drawing rectangle")
}
}
Метод render() может быть унаследован без изменения, а draw() — обязан быть реализован. Такой подход уменьшает дублирование и позволяет вводить новые методы в интерфейсы без разрушения совместимости.
Data-классы
Особое внимание в Kotlin уделено классам, основная цель которых — хранение данных. В таких случаях разработчик обычно ожидает наличие стандартных методов: сравнения (equals()), хеширования (hashCode()), строкового представления (toString()), копирования (copy()). В Java их приходится писать вручную (или генерировать IDE), что порождает шаблонный, многострочный код, подверженный ошибкам.
Kotlin решает эту проблему через ключевое слово data:
data class User(val id: Int, val name: String)
Для такого класса компилятор автоматически генерирует:
equals(other: Any?)иhashCode(), основанные на всех свойствах, объявленных в первичном конструкторе;toString(), возвращающий читаемое представление видаUser(id=1, name=Alice);copy(...), позволяющий создать копию объекта с изменением отдельных полей, например:user.copy(name = "Bob").
Важно: генерируемые методы учитывают только свойства из первичного конструктора. Поля, объявленные внутри тела класса, в семантику equals и hashCode не входят — это осознанное ограничение, обеспечивающее предсказуемость и соответствие интуитивным ожиданиям. Data-классы также деструктурируются в выражениях вида val (id, name) = user, что упрощает работу с кортежами и возвратом нескольких значений из функций.
Data-классы не обязаны быть immutable, но практика показывает, что их чаще всего объявляют с val, что способствует написанию более надёжного, потокобезопасного кода.
Статические члены и компаньонные объекты
Kotlin не имеет понятия «статических методов» или «статических полей» в том виде, как они существуют в Java. Вместо этого вводится концепция компаньонных объектов — объектов, принадлежащих классу и инициализируемых вместе с ним:
class Database {
companion object {
const val VERSION = "2.1"
fun connect(url: String): Connection { ... }
}
}
// Использование:
val version = Database.VERSION
val conn = Database.connect("jdbc:...")
companion object — это полноценный singleton-объект, вложенный в класс. Он может реализовывать интерфейсы, наследоваться от других классов, содержать свойства и методы. При компиляции в байт-код JVM такие члены преобразуются в статические поля и методы, что обеспечивает полную совместимость с Java.
Для констант, известных на этапе компиляции, предпочтительно использовать const val внутри компаньонного объекта — это позволяет компилятору встраивать их значение напрямую в клиентский код (как static final в Java), повышая производительность.
Функции высшего порядка, лямбды и замыкания
Хотя Kotlin — не функциональный язык в строгом смысле, он предоставляет мощные средства для функционального стиля программирования, включая функции высшего порядка, лямбда-выражения и замыкания. Это не противоречит ООП — наоборот, расширяет его: функции становятся полноценными объектами первого класса, которые можно передавать, хранить и возвращать.
Лямбда-выражение — это анонимная функция, заключённая в фигурные скобки:
val sum: (Int, Int) -> Int = { a, b -> a + b }
Здесь (Int, Int) -> Int — тип функции: два целочисленных аргумента, возвращающая Int. Выражение { a, b -> a + b } создаёт экземпляр Function2<Int, Int, Int>, который совместим с Java-интерфейсом java.util.function.BiFunction.
Функция высшего порядка принимает другую функцию в качестве параметра:
fun process(n: Int, block: (Int) -> Unit) {
block(n)
}
process(5) { println(it) } // "it" — неявный параметр по умолчанию
Вызов process(5) { ... } использует синтаксический сахар — если последний аргумент является лямбдой, её можно вынести за скобки. Если лямбда — единственный аргумент, скобки вообще опускаются. Это делает код похожим на встроенные управляющие конструкции, несмотря на то, что process — обычная функция.
Kotlin поддерживает замыкания: лямбда захватывает переменные из окружающей области видимости, и они остаются доступными даже после выхода из функции, в которой были объявлены. При этом захваченные val-переменные используются по значению, а var — по ссылке (через обёртку Ref<T>), что позволяет им сохранять изменённое состояние между вызовами.
Такой подход позволяет элегантно реализовывать стратегии, обработчики событий, асинхронные цепочки и другие паттерны, где поведение параметризуется кодом, а не только данными.
Расширения
Одной из наиболее выразительных возможностей Kotlin является механизм расширений (extensions) — способ добавления новых функций и свойств к существующим классам без изменения их исходного кода и без наследования. Это позволяет обогащать сторонние или стандартные типы поведением, специфичным для конкретного контекста, сохраняя при этом чистоту архитектуры и избегая «разбухания» базовых классов.
Синтаксис расширения прост: имя получателя указывается перед именем функции как префикс:
fun String.addExclamation(): String {
return this + "!"
}
// Использование:
println("Hello".addExclamation()) // Hello!
Здесь String.addExclamation() — это расширяющая функция, доступная для любого экземпляра String. Внутри тела функции this ссылается на получатель (receiver), то есть на строку, для которой вызван метод.
Важно понимать: расширения не модифицируют исходный класс. Они компилируются в статические вспомогательные методы, принимающие получатель в качестве первого параметра. На уровне JVM код str.addExclamation() превращается в вызов ExtensionsKt.addExclamation(str). Это означает:
- расширения не могут получить доступ к
privateилиprotectedчленам класса; - они не участвуют в полиморфизме — выбор конкретной реализации происходит статически, на этапе компиляции, а не динамически, как при переопределении методов;
- если в классе уже существует метод с такой же сигнатурой, он имеет приоритет над расширением.
Аналогично можно объявлять расширяющие свойства:
val String.isPalindrome: Boolean
get() = this.lowercase() == this.lowercase().reversed()
Обратите внимание: расширяющее свойство не хранит состояние — оно всегда реализуется через геттер (и, при необходимости, сеттер), поскольку в классе получателя физически нет соответствующего поля.
Расширения особенно эффективны в связке с обобщёнными типами, функциями высшего порядка и специализированными DSL. Например, библиотека kotlinx.html строит HTML-разметку с помощью расширений, превращая вызовы в декларативные блоки вида div { h1 { +"Title" } }. Это не синтаксический сахар компилятора — это полноценный механизм, реализованный средствами языка.
Защита от ошибок, связанных с отсутствием значения
Одной из наиболее значимых и практически полезных особенностей Kotlin является встроенная система типов с явной поддержкой отсутствия значения. Понятие null не устранено (так как необходимо для совместимости с JVM и внешними API), но его использование жёстко регламентировано: тип, допускающий значение null, должен быть объявлен явно с помощью суффикса ?.
Пример:
var name: String = "Alice" // не может быть null
var nullableName: String? = null // может быть null
Попытка присвоить null переменной типа String приведёт к ошибке компиляции. Аналогично, вызов метода напрямую на nullableName (например, nullableName.length) запрещён — компилятор потребует обработки возможного отсутствия значения.
Для безопасной работы с nullable-типами Kotlin предоставляет несколько механизмов:
-
Проверка через
if:if (nullableName != null) {
println(nullableName.length) // внутри ветки nullableName имеет тип String
} -
Оператор безопасного вызова
?.:val length = nullableName?.length // тип Int? -
Оператор Элвиса
?:для задания значения по умолчанию:val name = nullableName ?: "Anonymous" -
Оператор утверждения
!!— явное подавление проверки (используется редко, только при уверенности в ненулевом значении):val length = nullableName!!.length // бросит NPE, если null
Система работает на уровне типов и анализируется статически. Это позволяет полностью исключить NullPointerException в коде, написанном на Kotlin, при условии корректного объявления типов. Исключения возможны только при взаимодействии с Java-кодом, где аннотации @Nullable и @NotNull помогают компилятору Kotlin восстановить информацию о nullability.
Такой подход кардинально повышает надёжность: ошибка проектирования, допускающая неконтролируемое распространение null-значений, выявляется на этапе компиляции, а не во время выполнения.
Корутины
Асинхронное и параллельное программирование — одна из самых сложных тем в разработке. Традиционные подходы (коллбэки, Future, ExecutorService) порождают сложный, трудночитаемый и подверженный ошибкам код. Kotlin предлагает единый, лёгкий и выразительный механизм — корутины (coroutines).
Корутина — это лёгковесная единица выполнения, управляемая пользовательским кодом, а не планировщиком ОС. В отличие от потоков, корутины не привязаны к конкретному системному потоку: они могут приостанавливаться (suspend), освобождая поток для выполнения другой работы, и возобновляться позже — возможно, уже в другом потоке. При этом синтаксис остаётся последовательным, без вложенности коллбэков.
Основные компоненты:
- ключевое слово
suspend, помечающее функцию, которая может приостанавливаться; - встроенные функции
launch,async,runBlockingдля запуска корутин; - диспетчеры (
Dispatchers.IO,Dispatchers.Default,Dispatchers.Main) для управления контекстом выполнения.
Пример:
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
delay(1000L)
println("World!")
}
println("Hello,")
}
Здесь runBlocking создаёт корутинную область и блокирует текущий поток до завершения всех дочерних корутин. launch запускает новую корутину, которая выполняется параллельно. Вызов delay(1000L) — это приостановка, а не блокировка потока: в течение этой секунды поток может выполнять другие задачи.
Функция delay объявлена как suspend, что означает: её можно вызывать только из других suspend-функций или корутинных блоков. Это обеспечивает статическую проверку корректности асинхронного кода: невозможно случайно вызвать приостанавливающую операцию в синхронном контексте.
Корутины не являются частью языка в строгом смысле — они реализованы в библиотеке kotlinx.coroutines, но настолько тесно интегрированы с языком (через suspend), что воспринимаются как встроенные. Механизм поддерживает структурированную конкурентность: корутины образуют иерархию, и отмена родительской корутины автоматически отменяет всех потомков, предотвращая утечки ресурсов.
Аннотации
Kotlin полностью поддерживает аннотации — метаданные, добавляемые к объявлениям классов, функций, свойств, параметров и других элементов. Аннотации не влияют на выполнение программы напрямую, но используются компилятором, фреймворками и инструментами для генерации кода, проверок, сериализации и других задач.
Синтаксис аналогичен Java:
@Deprecated("Use newFunction() instead", ReplaceWith("newFunction()"))
fun oldFunction() { ... }
@JvmStatic
fun utility() { ... }
Некоторые аннотации специфичны для Kotlin и влияют на генерацию JVM-байт-кода:
@JvmStatic— размещает метод в классе какstatic, а не в компаньонном объекте;@JvmOverloads— генерирует перегруженные Java-методы для параметров по умолчанию;@JvmField— преобразует свойство в публичное поле без геттера и сеттера;@JvmName— задаёт альтернативное имя для Java-вызова.
Аннотации могут иметь параметры, включая классы, массивы и лямбды. Они поддерживают удержание (Retention), цель (Target) и наследование, как и в Java. Благодаря полной совместимости, Kotlin-код может использовать аннотации из Spring, JPA, JUnit и других Java-фреймворков без ограничений.
Перегрузка операторов
Kotlin позволяет переопределять поведение арифметических, логических и других операторов для пользовательских типов — но только для заранее определённого набора. Это строго регламентированная функциональность: каждый оператор связан с конкретным именем функции (например, + — с plus, == — с equals, [i] — с get(i)), и компилятор заменяет операторный вызов на вызов соответствующего метода.
Пример:
data class Point(val x: Int, val y: Int) {
operator fun plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
}
val p1 = Point(1, 2)
val p2 = Point(3, 4)
val p3 = p1 + p2 // эквивалентно p1.plus(p2)
Ключевое слово operator обязательно — оно сигнализирует, что метод предназначен для поддержки синтаксиса операторов. Без него запись p1 + p2 не компилируется.
Поддерживаемые операторы включают:
- арифметические:
+,-,*,/,%,++,--; - операторы присваивания:
+=,-=и т.д.; - сравнения:
==,!=,<,<=,>,>=(черезequals,compareTo); - индексные операции:
a[i],a[i] = v; - вызов как функции:
a(); - диапазоны:
..,until.
Важно: семантика операторов должна соответствовать ожиданиям. Например, == всегда вызывает equals, и его нельзя переопределить иначе — это гарантирует, что равенство остаётся рефлексивным, симметричным и транзитивным. Перегрузка == вручную невозможна: вместо этого переопределяется equals.
Такой подход обеспечивает выразительность без потери предсказуемости: операторы остаются просто синтаксическим сахаром над именованными методами, и их поведение можно всегда проследить в исходном коде.
Взаимодействие с Java
Kotlin разрабатывался с приоритетом на полный bidirectional interoperability с Java. Это не просто возможность вызывать Java из Kotlin — это гарантия того, что:
- любой Kotlin-класс может быть использован из Java без обёрток;
- любой Java-класс доступен в Kotlin с улучшенным синтаксисом;
- артефакты (JAR-файлы) Kotlin и Java могут свободно смешиваться в одном проекте.
Компилятор Kotlin генерирует стандартный JVM-байт-код, совместимый с Java 6 и выше. Все свойства преобразуются в private-поля с публичными getter/setter-методами, соответствующими соглашениям JavaBeans. Data-классы генерируют equals, hashCode, toString в том же формате, что и IDE-генераторы в Java. Компаньонные объекты становятся вложенными статическими классами с именем Companion, а их члены — статическими методами и полями.
Обратная совместимость также обеспечена: из Java можно вызывать Kotlin-код, используя сгенерированные методы напрямую:
// Java
User user = new User(1, "Alice");
String s = user.toString(); // вызывает сгенерированный toString()
User copy = user.copy(2, "Bob"); // метод copy доступен как copy(int, String)
Для улучшения взаимодействия Kotlin предоставляет специальные аннотации (@JvmName, @JvmStatic, @JvmOverloads), а также распознаёт Java-аннотации @Nullable и @NotNull (из javax.annotation, androidx.annotation, org.jetbrains.annotations), чтобы корректно обрабатывать nullability.
Это позволяет постепенно мигрировать проекты: отдельные модули или классы переписываются на Kotlin, в то время как остальная часть остаётся на Java — без необходимости переписывать всю систему целиком.
Делегирование
Kotlin реализует фундаментальный принцип объектно-ориентированного проектирования — предпочитать композицию наследованию — не только как рекомендацию, но как встроенную языковую конструкцию: ключевое слово by позволяет реализовать интерфейс через делегирование за одну строку.
Идея проста: если класс декларирует реализацию интерфейса, но не содержит собственной логики для его методов, он может передать эту ответственность другому объекту — делегату. При этом компилятор автоматически создаёт реализацию всех методов интерфейса, перенаправляя вызовы делегату.
Пример:
interface Printer {
fun print(message: String)
}
class ConsolePrinter : Printer {
override fun print(message: String) = println(message)
}
class LoggingPrinter(printer: Printer) : Printer by printer {
// Весь интерфейс Printer реализован через printer
}
Здесь LoggingPrinter реализует Printer, но не содержит ни одного метода. Компилятор генерирует:
// Эквивалент на Java:
public final class LoggingPrinter implements Printer {
private final Printer printer;
public LoggingPrinter(Printer printer) {
this.printer = printer;
}
public void print(String message) {
this.printer.print(message);
}
}
Такой подход исключает дублирование кода, упрощает сопровождение и повышает гибкость: логика может быть заменена в runtime, если делегат объявлен как var (хотя по умолчанию — val).
Делегированные свойства
Ключевое слово by также применяется к свойствам через механизм делегированных свойств (delegated properties). Он позволяет вынести логику хранения и доступа к значению в отдельный объект-делегат, реализующий стандартный интерфейс ReadWriteProperty или ReadOnlyProperty.
Стандартная библиотека Kotlin предоставляет несколько встроенных делегатов:
-
lazy— отложенная инициализация (однократная, потокобезопасная по умолчанию):val config by lazy { loadConfig() } -
Delegates.observable— наблюдение за изменениями:var name: String by Delegates.observable("Anonymous") { _, old, new ->
println("Name changed from $old to $new")
} -
Delegates.vetoable— возможность отклонить изменение на основе условия.
Делегированные свойства особенно эффективны в связке с фреймворками: например, в Android by viewBinding() или в Ktor by inject(). При этом семантика остаётся прозрачной: x = 5 и x внутри тела функции работают так же, как с обычным свойством, несмотря на то, что доступ контролируется внешним кодом.
Делегаты не нарушают инкапсуляцию — они не получают прямого доступа к внутренним данным класса, а взаимодействуют только через контракт интерфейса.
Sealed-классы
Одной из центральных проблем при работе с иерархиями типов является необходимость обработки всех возможных подтипов. В традиционных ООП-языках instanceof-проверки или switch по типу не являются исчерпывающими: добавление нового подкласса не вызывает ошибок компиляции, что приводит к неполному покрытию логики и runtime-сбоям.
Kotlin решает эту проблему через sealed-классы (запечатанные классы) — специальный вид абстрактных классов, для которых множество прямых подклассов фиксировано на этапе компиляции и должно быть объявлено в том же файле.
Пример:
sealed class Result {
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()
object Loading : Result()
}
Здесь Result не может иметь подклассов вне этого файла. Это позволяет компилятору проверять полноту при сопоставлении с образцом (when):
fun handle(result: Result) = when (result) {
is Result.Success -> println("Data: ${result.data}")
is Result.Error -> println("Error: ${result.message}")
Result.Loading -> println("Loading...")
// Если добавить новый подкласс без добавления ветки — ошибка компиляции
}
Ключевые особенности:
- sealed-классы неявно
open, но могут наследоваться только локально; - подклассы могут быть как классами, так и объектами (singleton’ами);
- ветви
whenне требуютelse, если перечислены все подтипы; whenс sealed-типом возвращает значение, что делает его полноценным выражением.
Sealed-классы особенно ценны при моделировании состояний (например, в архитектурах MVI), сетевых ответов, парсеров и конечных автоматов — везде, где требуется гарантия исчерпывающей обработки вариантов.
Внутренние и вложенные классы
Kotlin различает два вида классов, объявленных внутри другого класса:
-
Вложенные классы (
nested class) — по умолчанию. Они не имеют доступа к экземпляру внешнего класса и ведут себя как статические вложенные классы в Java.class Outer {
private val x = 10
class Nested {
fun foo() = "Independent" // нет доступа к x
}
} -
Внутренние классы (
inner class) — явно помеченные ключевым словомinner. Они содержат неявную ссылку на экземпляр внешнего класса и могут обращаться к его членам.class Outer {
private val x = 10
inner class Inner {
fun foo() = "x = $x" // доступ к x возможен
}
}
val outer = Outer()
val inner = outer.Inner() // создание требует экземпляра Outer
На уровне JVM inner class компилируется в отдельный класс с синтетическим полем this$0, ссылающимся на внешний объект — точно так же, как и в Java.
Выбор между nested и inner — архитектурное решение:
nestedиспользуется, когда вложенная сущность логически принадлежит пространству имён внешнего класса, но не зависит от его состояния (например, вспомогательные builder’ы, DTO, специализированные исключения);inner— когда требуется доступ к закрытым или защищённым членам внешнего класса, и экземпляр внутреннего класса семантически не существует без внешнего (например, итераторы, обработчики событий, делегаты представления).
Kotlin не позволяет объявлять внутренние классы внутри интерфейсов или объектов — только внутри классов, что исключает неоднозначность.
Объектные выражения и компаньоны как реализация паттернов
Помимо companion object, Kotlin поддерживает объектные выражения — анонимные реализации классов или интерфейсов, похожие на Java-анонимные классы, но с расширенными возможностями.
Синтаксис:
val comparator = object : Comparator<String> {
override fun compare(a: String, b: String): Int {
return a.length - b.length
}
}
Объектное выражение создаёт новый анонимный класс и единственный экземпляр этого класса (singleton в локальной области). В отличие от Java, такие выражения могут:
-
наследовать от класса и реализовывать несколько интерфейсов одновременно:
object : BaseClass(), InterfaceA, InterfaceB { ... } -
содержать собственные свойства и методы, недоступные извне, но используемые внутри:
val handler = object {
private var count = 0
fun onClick() {
count++
println("Clicked $count times")
}
}
handler.onClick() // допустимо; handler имеет тип этого анонимного объекта
Эта возможность позволяет реализовывать паттерн одиночка (Singleton) без boilerplate-кода private constructor + getInstance(): просто объявляется object, и его экземпляр создаётся lazily и потокобезопасно при первом доступе.
Пример использования в качестве фабрики:
interface ConnectionFactory {
fun create(): Connection
}
object DatabaseConnectionFactory : ConnectionFactory {
override fun create(): Connection {
return DriverManager.getConnection(url, user, pass)
}
}
Здесь DatabaseConnectionFactory — глобально доступный, лениво инициализированный singleton, реализующий интерфейс. Никакого дополнительного кода для управления жизненным циклом не требуется.